<?php
/**
 * Handles an SMB connection using the 'smbclient' Unix command.
 */
class smb_connection {

    /** Path to smbclient */
    const SMBCLIENTPATH='/usr/bin/smbclient';
    /** How long to allow for timeouts in case of error */
    const TIMEOUT=30;

    /** Process handle */
    private $process=0;
    /** Process communication pipes */
    private $stdin,$stdout,$stderr;
    /** True if debug mode is set */
    private $debug;

    /** Current folder (as read from prompt) */
    private $currentfolder;

    /**
     * Connects to SMB share. The connection will be maintained until the object
     * is destroyed, which should happen when you set all references to null
     * or it otherwise goes out of scope.
     * @param string $address Samba address e.g. //esaki/lts-ebooks
     * @param string $username NT username
     * @param password $password NT password
     * @param string $domain NT domain
     * @param boolean $debug If true, echoes all communication to output.
     *   If set to string 'EXTREME', does horrible logging.
     * @throws Exception if there is an error.
     */
    function __construct($address,$username,$password,$domain,$debug=false) {

        $this->debug=$debug;

        $pipes=array();
        if(!$this->process=proc_open(
            self::SMBCLIENTPATH.' '.$address. ' -E --user='.$username.' --workgroup='.$domain,
        		array(
                0 => array("pipe", "r"),
                1 => array("pipe", "w"),
                2 => array("pipe", "w")
                ),
            $pipes,null,array(
            		'CLI_FORCE_INTERACTIVE'=>'y' // Needed or the prompt isn't displayed!!
            ))) {
            throw new Exception('Error opening process');
        }
        $this->stdin  =& $pipes[0];
        $this->stdout =& $pipes[1];
        $this->stderr =& $pipes[2];

        $this->write_command($password);
        $craperr='';
        $this->read_until_prompt($craperr);
    }

    /**
     * Changes directory on the server.
     * @param string $folder New folder
     */
    function cd($folder) {
        $folder=str_replace('/','\\',$folder);
        $this->write_command('cd "'.$folder.'"');
        $stderr='';
        $this->read_until_prompt($stderr);
        if($stderr!='') {
            throw new Exception("Error when attempting to change folder: $stderr");
        }
    }

    /**
     * Gets a file from the server.
     * @param string $filename Name of file to get
     * @param string $targetpath Target path (absolute path) on which to store it
     * @return mixed Average download speed (KB/s), may be 'inf'!
     */
    function get($filename,$targetpath) {
        $filename=str_replace('/','\\',$filename);
        if(!$realpath=realpath(dirname($targetpath))) {
            throw new Exception("Target path does not exist: $targetpath");
        }
        $realpath.='/'.basename($targetpath);
        $this->write_command('get "'.$filename.'" "'.$realpath.'"');
        $stderr='';
        $this->read_until_prompt($stderr);
        $matches=array();
        if(!preg_match(
            '/^getting file .+ of size ([0-9]+) as .+ \(([0-9.]+|inf) kb\/s\) \(average ([0-9.]+|inf) kb\/s\)$/',
            trim($stderr),$matches)) {
            throw new Exception("Error when attempting to get file, response did not match expected string: $stderr");
        }
        if(filesize($targetpath)!=$matches[1]) {
            throw new Exception("Retrieved file size mismatch: expecting $matches[1] bytes.");
        }
        return $matches[3];

    }

    /**
     * Transfer a file to the server.
     * @param string $sourcefilepath Full path to the to the file to put
     * @param string $targetfilename Name for the file on the receiving end.
     * @return mixed Average download speed (KB/s), may be 'inf'!
     */
    function put($sourcefilepath,$targetfilename) {
        $targetfilename=str_replace('/','\\',$targetfilename);
        if(!file_exists($sourcefilepath)) {
            throw new Exception("Source file does not exist: $sourcefilepath");
        }
        $this->write_command('put "'.$sourcefilepath.'" "'.$targetfilename.'"');
        $stderr='';
        $this->read_until_prompt($stderr);
        $matches=array();
        if(!preg_match(
            '/^putting file .+ as .+ \(([0-9.]+|inf) kb\/s\) \(average ([0-9.]+|inf) kb\/s\)$/',
            trim($stderr),$matches)) {
            throw new Exception("Error when attempting to put file, response did not match expected string: $stderr");
        }
        return $matches[2];
    }

    /**
     * Deletes a file from the server.
     * @param string $filename Name of file to delete
     */
    function delete($filename) {
        $filename=str_replace('/','\\',$filename);
        $this->write_command('del "'.$filename.'"');
        $stderr='';
        $this->read_until_prompt($stderr);
        if($stderr!='') {
            throw new Exception("Unexpected response to DEL \"$filename\": $stderr");
        }
    }

     /**
     * Empties and deletes a folder from the server. 
     * @param string $folder Name of folder to delete
     */
    function recursive_delete($folder) {
        $folder=str_replace('/','\\',$folder);
        // first empty the folder
        $this->write_command('del *');
        $stderr='';
        $this->read_until_prompt($stderr);
        if($stderr!='') {
            throw new Exception("Unexpected response to DEL *: $stderr");
        }
        $this->cd('../'); // move back up to the containing folder
        $this->write_command('rmdir "'.$folder.'"');
        $stderr='';
        $this->read_until_prompt($stderr);
        if($stderr!='') {
            throw new Exception("Unexpected response to DEL \"$folder\": $stderr");
        }
    }
    
    /**
     * Recursively get a directory listing from this folder and every child one.
     * @param string $mask Optional file wildcard mask to restrict the listing.
     * @return array Array of all items including both folders and files (see dir() for details).
     */
    function recursive_dir($mask=null) {
        set_time_limit(self::TIMEOUT + 10); // Reset time limit with each folder in case directory takes a long time
        $fileshere=$this->dir($mask);
        $result=$fileshere;

        foreach($fileshere as $file) {
            if($file->isfolder) {
                $this->cd($file->filename);
                $result=array_merge($result,$this->recursive_dir($mask));
                $this->cd('..');
            }
        }

        return $result;
    }

    /**
     * Obtains a directory listing. Returned file items have the following fields:
     * parentfolder, filename, isfolder, ishidden, size, and timestamp.
     * Date will be wrong if the SMB server isn't in the local timezone.
     * @param string $mask Optional file wildcard mask to restrict the listing.
     * @param bool $allowdoublespace For internal use only
     * @return array Array of items.
     */
    function dir($mask=null,$allowdoublespace=false) {
        $this->write_command('dir'. ($mask ? ' "'.$mask.'"' : ''));
        $stderr='';
        $this->read_until_prompt($stderr);
        $items=array();
        foreach(explode("\n",$stderr) as $line) {
            $matches=array();
            if(preg_match('/^\s*(.*?)\s\s\s\s+(?:([DHARS]*)\s+)?([0-9]+)\s+(.*)$/',$line,$matches)) {
                // Ignore stupid entries
                if($matches[1]=='.' || $matches[1]=='..') {
                    continue;
                }
                // Build data item
                $item = new stdClass;
                $item->parentfolder=str_replace('\\','/',$this->currentfolder);
                $item->filename=$matches[1];

                // If item contains two spaces it may indicate a botched filename
                // from the SMB client (one with a non-breaking space in). Check
                // whether the file really exists...
                if(($doublespace=strpos($item->filename,'  '))!==false && !$allowdoublespace) {
                    $individual=$this->dir($item->filename,true);
                    if(count($individual)==0) {
                        throw new Exception("Filename includes invalid character after '".
                            substr($item->filename,0,$doublespace)."' in folder '{$this->currentfolder}'");
                    }
                }

                $item->isfolder=strpos($matches[2],'D')!==FALSE;
                $item->ishidden=strpos($matches[2],'H')!==FALSE;
                $item->isarchive=strpos($matches[2],'A')!==FALSE;
                $item->size=$matches[3];
                $item->timestamp=strtotime($matches[4]);
                $items[] = $item;
                unset($item);
            } else if(preg_match('/^\s*[0-9]+ blocks of size/',$line)) {
                // Ignore, summary line
            } else if(preg_match('/^\s*$/',$line)) {
                // Ignore, blank
            } else if(preg_match('/^NT_STATUS_NO_SUCH_FILE/',$line)) {
                // Matching single file which didn't exist
                return $items;
            } else {
                throw new Exception("Filename includes invalid character after '".
                    substr($line,2)."' in folder '{$this->currentfolder}', or unexpected response pattern.\n\nLine:\n$line");
            }

        }
        return $items;
    }

    /**
     * Recursively get the current directory and all its contents
     * to the directory $target on the local files system (no trailing slash).
     * @param string $target Where to copy the files to.
     * @param string $mask Optional file wildcard mask as for recursive_dir().
     */
    function recursive_get($target, $mask=null, $callback = '') {
        set_time_limit(self::TIMEOUT + 10); // Reset time limit with each folder in case a big file takes a long time
        $fileshere = $this->dir($mask);

        foreach($fileshere as $file) {
            $newtarget = $target . '/' . $file->filename;
            if ($callback) {
                $callback($newtarget);
            }
            if($file->isfolder) {
                $this->cd($file->filename);
                @mkdir($newtarget);
                $this->recursive_get($newtarget, $mask, $callback);
                $this->cd('..');
            } else {
                $this->get($file->filename, $newtarget);
            }
        }
    }

    /**
     * Sends a command to the SMB server.
     * @param string $command Command to send
     */
    private function write_command($command) {
        if($this->debug) {
            echo "<div>Sent to smbclient:<pre>$command</p></div>";
        }
        fwrite($this->stdin,$command."\n");
        fflush($this->stdin);
    }

    /**
     * Reads data from the SMB server until it receives a prompt.
     * The prompt line itself is ignored.
     * @param string &$input Variable that receives data
     * @throws Exception if there's an error or timeout
     */
    private function read_until_prompt(&$input) {
        $input='';
        $crap=array();

        while(true) {
            // Wait for an update on any stream
            $reading=array($this->stderr,$this->stdout);
            if(stream_select($reading,$crap,$crap,self::TIMEOUT)===false) {
                throw new Exception('Error in select');
            }

            // Check which are available
            $gotstderr=in_array($this->stderr,$reading);
            $gotstdout=in_array($this->stdout,$reading);
            if(!$gotstdout && !$gotstderr) {
								if($this->debug==='EXTREME') { print_object('--NOTHING TO READ--'); exit; }
                throw new Exception("Unexpected timeout in SMB client [After $input]");
            }
            
            // Read stderr first (if available)
            if($gotstderr) {
								if($this->debug==='EXTREME') { print_object('--STDERR--'); $thistime=''; }
            
						  	do {
                    $thing=fgetc($this->stderr);
                    if($thing===FALSE) {
												if($this->debug==='EXTREME') { print_object($thistime); print_object('--TERMINATED--'); exit; }
	                    	throw new Exception("Unexpected termination of SMB client [After $input]");
                    } else {
                        $input.=$thing;
												if($this->debug==='EXTREME') { $thistime.=$thing; }
                    }

                    if($this->got_prompt($input)) {
                        return;
                    }

                    $testing=array($this->stderr);
                    $stillgot=stream_select($testing,$crap,$crap,0);
                    if($stillgot===false) {
                        throw new Exception('Error in select');
                    }
                } while($stillgot);
								if($this->debug==='EXTREME') { print_object($thistime); }
            }

            // We have now read all available stderr. Read stdout if there is
            // any. (Just one byte - we'll catch it again in main select.)
            if($gotstdout) {
								if($this->debug==='EXTREME') { print_object('--STDOUT--'); $thistime=''; }
            		do {
                    $thing=fgetc($this->stdout);
                    if($thing===FALSE) {
												if($this->debug==='EXTREME') { print_object($thistime); print_object('--TERMINATED--'); exit; }
		                    throw new Exception("Unexpected termination of SMB client [After $input]");
                    } else {
                        $input.=$thing;
												if($this->debug==='EXTREME') { $thistime.=$thing; }
                    }

                    if($this->got_prompt($input)) {
                        return;
                    }

                    $testing=array($this->stdout);
                    $stillgot=stream_select($testing,$crap,$crap,0);
                    if($stillgot===false) {
                        throw new Exception('Error in select');
                    }
                } while($stillgot);
								if($this->debug==='EXTREME') { print_object($thistime); }
            }
        }
    }

    /**
     * @param string &$text Input text so far
     * @return bool True if prompt was found (in which case, $text should be returned)
     */
    function got_prompt(&$text) {
        $regexp='/^(.*\n)?(?:smb: ([^\n]*?))> $/s';
        $matches=array();
        if(preg_match($regexp,$text,$matches)) {
            $this->currentfolder=$matches[2];
            $text=preg_replace($regexp,'$1',$text);
            // OK read up to prompt
            if($this->debug) {
                print '<div>Read from smbclient:<pre>'.htmlspecialchars($text).'</pre>[Current folder '.htmlspecialchars($this->currentfolder).']</div>';
            }
            return true;
        } else {
        	return false;
        }
    }

    /** Destructor closes the connection gracefully. */
    public function __destruct() {
        if($this->process) {
            $this->write_command('exit');
            fclose($this->stdin);
            fclose($this->stderr);
            $result=proc_close($this->process);
            if($this->debug) {
                print "<div>Closed smbclient with return $result</div>";
            }
            $this->process=0;
        }
    }
}

define('EXN_OU_BASE',        10700);

define('EXN_OU_SMBADDRESS',  EXN_OU_BASE+1);
define('EXN_OU_FS_SMB',      EXN_OU_BASE+2);
// Content share ordinarily used for data
define('OU_STANDARDCONTENTSHARE','//Dog/PrintLive/');
// Alternate content share used for requests to the standard one that fail
define('OU_FALLBACKCONTENTSHARE','//Dog/PrintArchive/');

/**
 * Obtains a complete folde over SMB.
 * @param string $address Full address of file e.g. \\share\folder\subfolder\
 * @param string $targetfile Target local folder to store the result in. The folder
 *   will be created if needed
 * @param bool $delete If set, deletes folder from source share after obtaining it
 * @throws Exception if anything goes wrong
 */
function get_smb_folder($address,$targetfile,$delete=false) {
    global $CFG;
    // Work out as share and folder
    $matches=array();
    $address=preg_replace('^\\\\^','/',$address);
    if(!preg_match('|^(//[^/]+/[^/]+)/(.*/)?([^/]+)$|',$address,$matches)) {
        throw new Exception('Not a valid SMB address: '.$address,EXN_OU_SMBADDRESS);
    }
    
    list($junk,$share,$folder,$file)=$matches;
    $folder .= $file;  

    // Prepare SMB connection
    $smb=new smb_connection($share,$CFG->systemwindowsuser,$CFG->systemwindowspassword,'OPEN-UNIVERSITY');
    if(is_string($folder) && $folder!=='' ) {
        $smb->cd($folder);
    }
    // Prepare folder
    if(!mkdir_recursive($targetfile)) {
        error('Error creating folder: '.$targetfile,EXN_OU_FS_SMB);
    }
    try {
        $smb->recursive_get($targetfile);
    } catch(Exception $e) {
        throw $e;
    }
    if($delete) {
        $smb->recursive_delete($file); // remove the current preview directory
    }
    unset($smb);
}

/**
 * Obtains a single file over SMB.
 * @param string $address Full address of file e.g. \\share\folder\subfolder\file.txt
 * @param string $targetfile Target local file to store the result in. The folder
 *   will be created if needed
 * @param bool $delete If set, deletes file from source share after obtaining it
 * @throws Exception if anything goes wrong
 */
function get_smb_file($address,$targetfile,$delete=false) {
    global $CFG;

    // Work out as share and folder
    $matches=array();
    $address=preg_replace('^\\\\^','/',$address);
    if(!preg_match('|^(//[^/]+/[^/]+)/(.*/)?([^/]+)$|',$address,$matches)) {
        throw new Exception('Not a valid SMB address: '.$address,EXN_OU_SMBADDRESS);
    }
    list($junk,$share,$folder,$file)=$matches;
    
    // Prepare SMB connection
    $smb=new smb_connection($share,$CFG->systemwindowsuser,$CFG->systemwindowspassword,'OPEN-UNIVERSITY');
    if(is_string($folder) && $folder!=='' ) {
        $smb->cd($folder);
    }
    
    // Prepare folder
    if(!mkdir_recursive(dirname($targetfile))) {
        throw new Exception('Error creating folder: '.dirname($targetfile),EXN_FS_SMB);
    }
    try {
        $smb->get($file,$targetfile);
    } catch(Exception $e) {
        // If there is a failure in PrintLive, try PrintArchive
        $matches=array();
        if(preg_match($re='~^'.str_replace('$','\$',OU_STANDARDCONTENTSHARE).'(.*)$~i',$address,$matches)) {
            get_smb_file(OU_FALLBACKCONTENTSHARE.$matches[1],$targetfile,$delete);
        } else {
            throw $e;
        }
    }
    if($delete) {
        $smb->delete($file);
    }
    unset($smb);
}

/**
 * Obtains a single file over SMB.
 * @param string $address Full address of file e.g. \\share\folder\subfolder\file.txt
 * @returns bool true if file exists
 * @throws Exception if anything goes wrong
 */
function smb_file_exists($address) {
    global $CFG;
    $address=preg_replace('^\\\\^','/',$address);
    if(!preg_match('|^(//[^/]+/[^/]+)/(.*/)?([^/]+)$|',$address,$matches)) {
        throw new Exception('Not a valid SMB address: '.$address,EXN_OU_SMBADDRESS);
    }
    list($junk,$share,$folder,$file)=$matches;

    // Prepare SMB connection
    $smb=new smb_connection($share,$CFG->systemwindowsuser,$CFG->systemwindowspassword,'OPEN-UNIVERSITY');
    $found=false;
    if(is_string($folder) && $folder!=='' ) {
        $smb->cd($folder);
    }
    foreach($smb->dir('*') as $folder) {
        if($folder->filename===$file) {
            return true;
            break;
        }
    }
    return false;
}
?>
